
/*
 * Copyright (C) 2008-2009 Archie L. Cobbs. All rights reserved.
 *
 * $Id: SidekarWebController.java 360 2010-02-24 22:11:10Z archie.cobbs $
 */

package org.dellroad.sidekar.spring;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.Queue;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.dellroad.sidekar.Database;
import org.dellroad.sidekar.Entity;
import org.dellroad.sidekar.Index;
import org.dellroad.sidekar.Item;
import org.dellroad.sidekar.SimpleField;
import org.dellroad.sidekar.query.QuerySet;
import org.dellroad.sidekar.xml.XMLImporter;
import org.dellroad.stuff.string.ParseContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;

/**
 * Spring web controller that provides REST style access to a {@link Database}.
 * When used in conjunction with a {@link SidekarXMLView}, output is rendered in the Sidekar XML export format.
 * The {@link #setDatabase sidekarDatabase} property is required.
 *
 * <p>
 * This class is already configured with {@link org.springframework.web.bind.annotation.RequestMapping @RequestMapping}
 * method-level annotations configured for accessing a {@link Database} according to the XML-based REST
 * style API documented below. These can be overridden in a subclass if desired.
 * </p>
 *
 * <p>
 * In any case, requests must still be mapped to this controller somehow. This can be done using a type-level
 * {@link org.springframework.web.bind.annotation.RequestMapping @RequestMapping} annotation on a subclass, or
 * by an explicit mapping, e.g.:
 * <blockquote><pre>
 *  &lt;context:component-scan base-package="org.dellroad.sidekar.spring"/&gt;
 *  &lt;bean class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping"&gt;
 *      &lt;property name="urlMap"&gt;
 *          &lt;map&gt;
 *              &lt;entry key="/mydatabase/**&#47;*" value-ref="sidekarWebController"/&gt;
 *          &lt;/map&gt;
 *      &lt;/property&gt;
 *  &lt;/bean&gt;
 * </pre></blockquote>
 * </p>
 *
 * <h4>REST API</h4>
 *
 * <p>
 * Each URL is described below with no URI prefix; typically there will be some URI prefix, depending on higher layer configuration
 * such as web.xml {@code <servlet-mapping>}, Spring handler mapping, etc.
 * </p>
 *
 * <div style="margin-left: 20px">
 * <table border="1" cellpadding="5" cellspacing="0">
 * <tr bgcolor="#ccffcc">
 *  <th align="left">URL</th>
 *  <th align="left">Description</th>
 * </tr>
 * <tr>
 *  <td style="white-space: nowrap"><code>GET /schema</code></td>
 *  <td>Retrieve the database schema.</td>
 * </tr>
 * <tr>
 *  <td style="white-space: nowrap"><code>GET /index/<b>ENTITY</b>/<b>INDEX</b><b>[</b>?maxResults=<b>NUM</b><b>]</b>
 *      <br/><b>[</b>&amp;values=<i>value1,value2,...</i><b>]</b>
 *      <br/><b>[</b>&amp;reverse=true<b>]</b></code></td>
 *  <td>Query the index <code><b>INDEX</b></code> of entity <code><b>ENTITY</b></code>,
 *      optionally specifying a maximum number of items to return, zero or more
 *      {@link SimpleField#toString(Object) string encoded} {@link SimpleField} field values corresponding to the indexed fields,
 *      and whether to reverse the sorted ordering.</td>
 * </tr>
 * <tr>
 *  <td style="white-space: nowrap"><code>GET /entity/<b>ENTITY</b><b>[</b>?maxResults=<b>NUM</b><b>]</b></code></td>
 *  <td>Retrieve all instances of <code><b>ENTITY</b></code>,
 *      optionally specifying a maximum number of items to return.</td>
 * </tr>
 * <tr>
 *  <td style="white-space: nowrap"><code>GET /singleton/<b>ENTITY</b><b>[</b>?field=<b>FIELD</b><b>]</b></code></td>
 *  <td>Retrieve the singleton instance of entity <code><b>ENTITY</b></code>,
 *      optionally specifying a specific field <code><b>FIELD</b></code>.</td>
 * </tr>
 * <tr>
 *  <td style="white-space: nowrap"><code>GET /item/<b>ENTITY</b>@<b>ID</b><b>[</b>?field=<b>FIELD</b><b>]</b></code></td>
 *  <td>Retrieve instance of <code><b>ENTITY</b></code> with id <code><b>ID</b></code>,
 *      optionally specifying a specific field <code><b>FIELD</b></code>.</td>
 * </tr>
 * <tr>
 *  <td style="white-space: nowrap"><code>POST /create/<b>ENTITY</b><b>[</b>?attached=<b>BOOLEAN</b><b>]</b></code></td>
 *  <td>Create and return a new instance of <code><b>ENTITY</b></code>, optionally specifying
 *      whether it should be attached or not (default {@code true}).</td>
 * </tr>
 * <tr>
 *  <td style="white-space: nowrap"><code>POST /data</td>
 *  <td>Import data in the Sidekar XML format. Only the {@code <data>} child of the root
 *      {@code <sidekar-database>} element is allowed, and no {@code name} attribute is permitted.</td>
 * </tr>
 * </table>
 * </div>
 * </p>
 */
@Controller
public class SidekarWebController {

    /**
     * The configured {@link Database}.
     *
     * @see #setDatabase
     */
    protected Database db;

    /**
     * The configured {@link View}, if any.
     *
     * @see #setView
     */
    protected View view;

    /**
     * Configure the {@link Database} that this instance will operate on.
     *
     * <p>
     * This property is required.
     */
    @Required
    @Autowired
    public void setDatabase(Database db) {
        this.db = db;
    }

    /**
     * Configure the {@link View} that this instance will use to render output.
     *
     * <p>
     * This property is optional; if not configured, the view must be resolved elsewhere.
     */
    @Autowired
    public void setView(View view) {
        this.view = view;
    }

///////////////////// HTTP REQUEST METHODS /////////////////////

    /**
     * Retrieve the schema associated with the configured {@link Database}.
     */
    @RequestMapping(value = "schema", method = RequestMethod.GET)
    public ModelAndView getSchema(HttpServletRequest req) {
        return new ModelAndView(this.view, SidekarXMLView.MARSHAL_OBJECT, this.db.getSchemaModel());
    }

    /**
     * Query an {@link org.dellroad.sidekar.Index Index}.
     */
    @RequestMapping(value = "index/*/*", method = RequestMethod.GET)
    public ModelAndView getEntityIndex(HttpServletRequest req,
      @RequestParam(value = "values", required = false) String values,
      @RequestParam(value = "reverse", required = false) Boolean reverse,
      @RequestParam(value = "maxResults", required = false) Integer maxResults) {

        // Find entity and index
        Queue<String> path = getPathInfo(req, "index");
        Entity<?> entity = this.db.getEntity(path.remove());
        Index<?> index = entity.getIndex(path.remove());

        // Parse index field values (if any)
        ArrayList<Object> indexValues = new ArrayList<Object>();
        if (values != null)  {
            ParseContext ctx = new ParseContext(values);
            String[] fieldNames = index.getFieldNames();
            for (int i = 0; i < fieldNames.length; i++) {
                if (ctx.isEOF())
                    break;
                if (i > 0)
                    ctx.expect(',');
                SimpleField<?> field = (SimpleField<?>)entity.getField(fieldNames[i]);
                indexValues.add(field.fromString(ctx));
            }
            if (ctx.getInput().length() > 0)
                throw ctx.buildException("`values' parameter has extra trailing garbage");
        }

        // Get iterator and reverse it if necessary
        QuerySet<?> querySet = index.query(indexValues);
        if (Boolean.TRUE.equals(reverse))
            querySet = querySet.descendingSet();

        // Build model+view
        ModelAndView mv = new ModelAndView(this.view, SidekarXMLView.MARSHAL_OBJECT, querySet.iterator());
        mv.addObject(SidekarXMLView.MAX_RESULTS, maxResults);
        return mv;
    }

    /**
     * Retrieve a singleton {@link Item}.
     */
    @RequestMapping(value = "singleton/*", method = RequestMethod.GET)
    public ModelAndView getEntitySingleton(HttpServletRequest req, @RequestParam(value = "field", required = false) String field) {
        Queue<String> path = getPathInfo(req, "singleton");
        return getItem((Item<?>)this.db.getEntity(path.remove()).getSingleton(), req, field);
    }

    /**
     * Retrieve instances of an {@link Entity}.
     */
    @RequestMapping(value = "entity/*", method = RequestMethod.GET)
    public ModelAndView getEntity(HttpServletRequest req,
      @RequestParam(value = "maxResults", required = false) Integer maxResults) {
        Queue<String> path = getPathInfo(req, "entity");
        Entity<?> entity = this.db.getEntity(path.remove());
        ModelAndView mv = new ModelAndView(this.view, SidekarXMLView.MARSHAL_OBJECT, entity.iterator());
        mv.addObject(SidekarXMLView.MAX_RESULTS, maxResults);
        return mv;
    }

    /**
     * Retrieve an {@link Item}.
     */
    @RequestMapping(value = "item/*", method = RequestMethod.GET)
    public ModelAndView getItem(HttpServletRequest req, @RequestParam(value = "field", required = false) String field) {
        Queue<String> path = getPathInfo(req, "item");
        return this.getItem(getItem(path.remove()), req, field);
    }

    /**
     * Create a new {@link Item} and return it.
     */
    @RequestMapping(value = "create/*", method = RequestMethod.POST)
    public ModelAndView createItem(HttpServletRequest req, @RequestParam(value = "attached", required = false) Boolean attached) {
        Queue<String> path = getPathInfo(req, "create");
        Entity<?> entity = this.db.getEntity(path.remove());
        Item<?> item = (Item<?>)entity.create();
        if (attached != null && !attached)
            item.sidekarDetach();
        return this.getItem(item, req, null);
    }

    /**
     * Update items based on the POST'ed XML tag input.
     */
    @RequestMapping(value = "data", method = RequestMethod.POST)
    public ModelAndView writeData(HttpServletRequest req) throws Exception {
        XMLImporter.importData(req.getInputStream(), this.db);
        return new ModelAndView(new HttpErrorView(HttpServletResponse.SC_OK, "Data accepted"));
    }

///////////////////// INTERNAL METHODS /////////////////////

    private ModelAndView getItem(Item<?> item, HttpServletRequest req, String field) {
        ModelAndView mv = new ModelAndView(this.view, SidekarXMLView.MARSHAL_OBJECT, item);
        if (field != null)
            mv.addObject(SidekarXMLView.FIELD_NAME, item.getSidekarEntity().getField(field).getName());
        return mv;
    }

    // Get an item from a Foo@1234 description if any, else return null
    private Item<?> getItem(String ident) {
        int at = ident.indexOf('@');
        if (at == -1)
            throw new RuntimeException("malformed item ident \"" + ident + "\"");
        Entity<?> entity = this.db.getEntity(ident.substring(0, at));
        long id;
        try {
            id = Long.parseLong(ident.substring(at + 1));
        } catch (NumberFormatException e) {
            throw new RuntimeException("malformed item ident \"" + ident + "\"");
        }
        return (Item<?>)entity.get(id);
    }

    // Split extra path info into components and skip over prefix
    private static Queue<String> getPathInfo(HttpServletRequest req, String keyword) {

        // Split request path info into components
        String pathInfo = req.getPathInfo();
        String[] infos = new String[0];
        if (pathInfo != null) {
            assert pathInfo.indexOf('/') == 0;
            infos = pathInfo.substring(1).split("/");
            if (infos.length == 1 && infos[0].length() == 0)
                infos = new String[0];
        }
        Queue<String> list = new LinkedList<String>(Arrays.asList(infos));

        // Strip off prefix up until through our request keyword
        while (true) {
            if (list.isEmpty())
                throw new RuntimeException("can't find request \"" + keyword + "\" in path-info \"" + pathInfo + "\"");
            if (list.remove().equals(keyword))
                break;
        }

        // Done
        return list;
    }
}

